Tips:本篇笔记附带了一份代码样例,查看 https://github.com/zchengb/orm-cache

1. 概述

MyBatis的缓存机制实质上说的是它的一级缓存与二级缓存

一级缓存说的是在同一个SqlSession中,反复地执行同一条查询SQL,在当第一次查询得到结果后,MyBatis会将该结果进行Session级别的缓存,打个比方,第一次查询到的是id = 1User对象,则在查询完成后,MyBatis会将该对象存储进一个哈希表(默认是HashMap)内,此后的每一次相同的id = 1User对象查询,则会直接返回哈希表内先前已有缓存的User对象

整个一级缓存的概念图如下所示:

MyBatis一级缓存

二级缓存说的是在多个SqlSession内,反复执行同一条SQL查询,会在跨Session范围内对查询结果进行缓存,以达到后续的相同SQL查询能够命中缓存进行快速返回

整个二级缓存的概念图如下所示:

image-20240728215258424

什么是SqlSession

每当使用MyBatis开启一次与数据库的会话时,MyBatis会创建出一个Session对象表示一次数据库会话,也就是一次与MySQL的链接

在正常业务开发过程中,实际上默认都是用不到一二级缓存的,当不通过手动开启事务或者无标注@Transactional注解的情况下,默认一次查询将会是一次独立的SqlSession,因此多次查询并不会共享缓存,Session级别的缓存会在每次查询后的会话关闭时进行销毁

2. 一级缓存

一级缓存的开启一般是通过手动开启一个SqlSession来确保会话内的相同SQL查询结果能够被正确地缓存下来,因为一级缓存是不跨SqlSession的,示例代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
SqlSession sqlSession = sqlSessionFactory.openSession();
try {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);

// 第一次查询,会访问数据库
User user1 = mapper.findById(1L);

// 第二次查询,相同的SqlSession,直接从缓存获取
User user2 = mapper.findById(1L);

System.out.println(user1 == user2); // 输出 true,表示是同一个实例
} finally {
sqlSession.close(); // 关闭SqlSession,缓存被清除
}

每个SqlSession中实际上都持有了Executor,其中Executor中有一个LocalCacheMyBatis会根据当前执行的语句来生成MappedStatement对象,并在Local Cache中查询,如果查询结果命中,则会直接返回查询结果,如果查询不命中的话,则会进一步查询数据库,并在查询完数据库后,将获取到的查询结果缓存到Local Cache中,相关的具体实现类如下所示:

img

一级缓存的目的在于减少数据库的访问次数,并减少不必要的连接创建,加快整体的查询效率,MyBatis的一级缓存有2种不同的级别,分别是:Session级别和Statement级别

  • Session级别:Session级别缓存是与MyBatis的SqlSession绑定的,在一个SqlSession的生命周期内,所有的查询结果都会被缓存,在同一个SqlSession内,相同的查询将直接从缓存中获取结果,而不会再次访问数据库,并在当SqlSession被关闭时,会顺带将其中的缓存进行清理
  • Statement级别:这个级别实际上相当于禁用了MyBatis的一级缓存策略,因为这个级别会在每次SqlSession查询结束后都进行缓存清理,具体源码可参考BaseExector ,如下所示:

DefaultExecutor源码

以下是几个有关MyBatis一级缓存的测试:

  1. 通过@Transactional促使方法内使用同一SqlSession,并确认重复执行SQL时实际仅会查询一次
1
2
3
4
5
6
7
8
9
@Test
@Transactional
void mybatis_query_within_same_session() {
var start = System.currentTimeMillis();
userMyBatisRepository.saveUser(new User("user1"));
IntStream.range(0, 1_000).forEach(i -> userMyBatisRepository.findById(1L).orElseThrow());
var end = System.currentTimeMillis();
log.info("mybatis_query_within_same_session: {} ms", end - start);
}

执行结果中显示只有一条SQL查询语句,耗时为137ms,执行结果如下所示:

image-20240728223924049

  1. 使用常规查询方式,验证每次查询都将创建新的SqlSession,进行数据库查询
1
2
3
4
5
6
7
8
9
@Test
void mybatis_query_without_same_session() {
var start = System.currentTimeMillis();
userMyBatisRepository.saveUser(new User("user1"));
IntStream.range(0, 1_000).forEach(i -> userMyBatisRepository.findById(1L).orElseThrow());
var end = System.currentTimeMillis();
log.info("mybatis_query_without_same_session: {} ms", end - start);
}

执行结果中显示每次查询都将直接请求数据库,1000次查询请求共计耗时1940ms,如下所示:

image-20240728230436560

  1. 验证当在同一SqlSession中,进行更新时会清除现有缓存
1
2
3
4
5
6
7
8
@Test
void mybatis_query_cache_will_be_clear_when_exec_update_in_same_sql_session() {
userMyBatisRepository.saveUser(new User("user1"));
var user = userMyBatisRepository.findById(1L).orElseThrow();
user.setName("user2");
userMyBatisRepository.saveUser(user);
userMyBatisRepository.findById(1L).orElseThrow();
}

执行结果中显示当第一次查询后再次进行更新,更新后再次进行查询时,会再从数据库中获取最新的数据,如下所示:

image-20240728231155137

  1. 当跨SqlSession时,查询后的缓存不会受到其中另一个SqlSession更新而失效
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Test
void mybatis_query_with_multiple_sql_session() {
var sqlSession1 = mybatisSqlSessionFactory.openSession(true);
var mapper1 = sqlSession1.getMapper(UserMapper.class);
var sqlSession2 = mybatisSqlSessionFactory.openSession(true);
var mapper2 = sqlSession2.getMapper(UserMapper.class);

var user = new User("old user name");
mapper1.insert(user);
var userId = user.getId();

var user1 = mapper1.selectById(userId);
log.info("before update user1 name: {}", user1.getName());

var user2 = mapper2.selectById(userId);
log.info("before update user2 name: {}", user2.getName());

user1.setName("new name");
mapper1.updateById(user1);
log.info("after update user1 name: {}", user1.getName());

user2 = mapper2.selectById(userId);
log.info("after update user2 name: {}", user2.getName());
}

执行结果如下所示:

image-20240728231554666


默认情况下,MyBatis的一级缓存使用的是PerpetualCache作为缓存类,其底层使用的是HashMap,并且这个哈希表没有做数量的限制,也就是说,如果长时间持续查询不同的对象,并且过程中没有执行任何的更新语句(更新语句会导致SqlSession清空缓存),则会导致后续底层HashMap过大,最后导致OOM

因此,MyBatis的一级缓存就是使用了简单的HashMap,MyBatis只负责将查询数据库的结果放置到缓存当中,不会去判断缓存的大小、过期时间等等

3. 二级缓存

二级缓存会在SQL执行前统一查询本地缓存表,这个本地缓存表是跨SqlSession的,也就是说在所有的SqlSession内都是共享的

MyBatis的Mapper可以通过@CacheNamespace来开启二级缓存,如下所示:

1
2
3
4
@Mapper
@CacheNamespace
public interface UserCacheMapper extends BaseMapper<User> {
}

而对应的使用方法如下:

1
2
3
4
5
6
7
8
9
10
@Autowired
private UserCacheMapper userCacheMapper;

@Test
void mybatis_l2_cache() {
var user = new User("old user name");
userCacheMapper.insert(user);
userCacheMapper.selectById(user.getId());
userCacheMapper.selectById(user.getId());
}

可以观察到整个使用过程中进行了3次数据库交互,对应是3个SqlSession,因为并没有开启事务,也没有通过SqlSessionFactory来创建统一的SqlSession,但实际上的执行效果确是在第二次查询时,从缓存中获取到了缓存值,从执行结果可以看出实际的数据库查询只发生了一次,如下所示:

image-20240728235024055

整个缓存策略实际上依赖的是TransactionalCacheManager,一般来说,在二级缓存执行流程后就会进入一级缓存的执行流程

4. 总结

MyBatis的二级缓存相对于一级缓存来说,实现了SqlSession之间缓存数据的共享,同时粒度更加的细,能够到namespace级别,通过Cache接口实现类不同的组合,对Cache的可控性也更强

一二级缓存都可能会由于缓存出现脏数据,在分布式环境下,由于默认的MyBatis Cache实现都是基于本地的,分布式环境下必然会出现读取到脏数据,需要使用集中式缓存将MyBatis的Cache接口实现,有一定的开发成本,直接使用Redis、Memcached等分布式缓存可能成本更低,安全性也更高,也就是说相比于MyBatis的一二级缓存来说,直接用分布式缓存或许会更香一些(不需要去实现二级缓存的底层自定义逻辑)